JVM 垃圾收集器与内存分配策略

确定回收的对象

「死去」的对象即不可能再被任何途径使用的对象。

几乎所有对象的实例都存在堆中。(部分 String 对象存在于字符串常量池中,JDK1.7 以前,字符串常量池存放在方法区中)。

进行回收的第一步就是确定哪些对象还活着,哪些已经死亡。

引用计数算法

给对象中添加一个引用计数器,

  • 每当有一个地方引用它时,计数器值就加 1;
  • 当引用失效时,计数器值就减 1;
  • 任何时刻计数器为 0 的对象就是不可能再被使用的。

缺点:很难解决对象之间相互循环引用的问题。因此 Java 虚拟机中没有采用引用计数算法来管理内存。

可达性分析算法

主流的商用程序语言的主流实现中,都是通过可达性分析来判断对象是否存活的。

算法的基本思路:

  • 通过一系列的称为 GC Root 的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为『引用链』(Reference Chain)
  • 当一个对象到 GC Root 没有任何引用链相连时,则证明此对象是不可用的

    GC Root 对象的定义是什么? Help - Eclipse Platform 上给的定义是这样的:

A garbage collection root is an object that is accessible from outside the heap

也就是说 GC Roots 是可以从堆外访问的对象。由 Java 运行时数据区可以知道,堆外的空间有虚拟机栈、本地方法栈以及方法区。而java 1.8 将整个方法区被移到一个叫元空间的地方(使用本地内存存储)

所以GC Roots 对象包含以下几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象,也就是局部变量引用的对象。
  • 本地方法栈中 JNI 引用的对象,包括
    • 本地方法栈中的局部变量或者参数
    • JNI全局引用
  • 方法区中类静态属性引用的对象由 Java 虚拟机自带的类加载器所加载的类,在虚拟机的生命周期中,始终不会被卸载。它们可以通过静态属性的方式持有对象的引用。注意,一般情况下由自定义的类加载器加载的类不能成为GC Roots
  • 方法区中常量引用的对象

引用分类

JDK 1.2 之后对,Java 对引用的概念进行了扩充(不再是仅有被引用或者没有被引用两种):

共有四种:

  • 强引用
    • 只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
  • 软引用 SoftReference
    • 内存不够时被回收
      • 在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。
  • 弱引用 WeakReference
    • 只要一执行 GC 就会被回收
  • 虚引用,也称为幽灵引用、幻影引用 PhantomReference
    • 『形同虚设』。一个对象被持有虚引用对其生命周期毫无影响
    • 虚引用的作用仅仅是在该对象被回收时,收到一个系统通知而已

生存还是死亡

一个对象的 finalize 方法最多只会被系统调用一次。

即使在可达性分析算法中不可达的对象,也并非是「非死不可」的,要真正宣告一个对象死亡,至少要经历两次标记过程

  • 如果对象在进行可达性分析后发现没有与 GCRoots 相连接的引用链,那它将会被第一次标记并且进行一次筛选
    • 筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或者 finalize 方法已经被虚拟机调用过,虚拟机将这两种情况都视为「没有必要执行」。
    • 如果这个对象被判定为有必要执行 finalize 方法,那么这个对象将会放置在一个叫做 F-Queue 的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。

这里所谓的「执行」是指虚拟机 会触发这个方法,但并不承诺会等待它运行结束,为什么要这么做?

  • 因为如果承诺得到 finalize 方法执行结束,那么当一个对象在 finalize 方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能会导致 F-Queue 队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。

finalize 方法是对象逃脱死亡命运的==最后一次机会==,稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记

  • 如果对象要在 finalize 中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this 关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除出「即将回收」的集合
  • 如果对象这时候还没有逃脱,那基本上它就真的被回收了。

任何一个对象的 finalize 方法只会被系统调用一次。

回收方法区

永久代的垃圾回收主要回收两部分的内容:

  • 废弃常量
  • 无用的类

回收废弃常量

回收废弃常量与回收 Java 堆中对象很相似。以常量池中字面量的回收为例,假如一个字符串「abc」已经进入了常量池中,它没有被引用,如果这时发生内存回收,而且必要的话,这个「abc」常量就会被系统清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。

回收无用类

类需要同时满足下面 3 个条件才能算是「无用的类」:

  1. 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  2. 加载该类的 ClassLoader 已经被回收
  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样,不使用了就必然会回收。 而且因为由 Java 虚拟机自带的类加载器所加载的类,在虚拟机的生命周期中,始终不会被卸载。所以正常情况下回收无用类收效甚微。

垃圾收集算法

标记-清除算法

标记-清除 (Mark-Sweep) 算法是最基础的收集算法,分为标记和清除两个阶段:

  • 首先标记出所有需要回收的对象,
  • 在标记完成后统一回收所有被标记的对象

之所以说它基础,是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到的。

主要不足有两个:

  • 效率问题:标记和清除两个过程的效率都不高;
  • 空间问题(确切而言是空间碎片问题):标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

mark-sweep

仅有 CMS 收集器使用了标记-清除算法。

复制算法

复制算法主要是为了解决效率问题,它将可用内存按容量划分为大小相等的两块每次只使用其中的一块。 当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。

商业虚拟机都采用复制收集算法来回收新生代,但并不是按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和==两块==较小的 ==Survivor== 空间,每次使用 Eden 和其中一块 Survivor,当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到==另外一块==Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。

  • HotSpot 虚拟机默认 Eden 和两块 Survivor 的大小比例是 8:1:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%(80%+10%),只有 10%的内存会被「浪费」。当 Survivor 空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。

缺点:

  • 使用空间换取时间,这种算法将内存缩小为了原来的一半
  • 复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费 50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100% 存活的极端情况,所以在老年代一般不能直接选用这种复制算法

copy sweep

标记-==整理==算法

标记-整理」算法标记过程仍然与「标记-清除」算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。(先移动到『安全位置』再清除掉安全区域边界外的无用位置)

mark-compact

分代收集算法

目前商业虚拟机的垃圾收集都采用「分代收集」(Generational Collection)算法

只是根据对象存活周期的不同将内存划分为几块。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。可以通过 -XX:NewRatio调整新生代与老年代的内存空间比例(指定老年代所占的「份数」)。默认情况下 NewRatio = 2,即: 新生代约占整个堆空间的 1/3 ,老年代约占 2/3 。

  • 新生代中,因为对象存活率比较低,只需要付出少量存活对象的复制成本就可以完成收集。选用复制算法
  • 老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用「标记-清理」或者「标记-整理」算法来进行回收。

HotSpot 的算法实现

如何发起内存回收?首先要知道回收的哪些对象。

枚举根节点

可作为 GC Roots 的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,现在很多应用仅仅方法区就有数百兆,如果要逐个检查这里面的引用,那么必然会消耗很多时间。

分析期间引用不可变导致 GC 停顿

可达性分析对执行时间的敏感还体现在 GC 停顿 上,因为这项分析工作必须在一个能确保一致性的快照中进行。

  • 一致性」的意思是指在整个分析期间,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果准确性就无法得到保证。这点是导致 GC 进行时必须停顿所有 Java 执行线程(Sun 将这件事情称为「Stop The World」)的其中一个重要原因,即使是在号称(几乎)不会发生停顿的 CMS 收集器中,枚举根节点时也是必须要停顿的

由于目前的主流 Java 虚拟机使用的都是准确式 GC,也就是说,虚拟机可以准确的知道内存中某个位置的数据类型是什么。所以当执行系统停顿下来后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机只要直接扫描存放对象引用的区域。

  • 在 HotSpot 的实现中,使用一组称为 OopMap 的数据结构来达到这个目的的,在类加载完成的时候,HotSpot 就把对象内什么偏移量上是什么类型的数据计算出来,在 JIT 编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC 在扫描时就可以直接得知这些信息了。

使用空间换时间。

安全点

从线程角度看,safepoint 可以理解成是在代码执行过程中的一些特殊位置,当线程执行到这些位置的时候,说明虚拟机当前的状态是安全的,(就好像高速公路上的服务区,可以安全地停下来休息)

  • 如果有需要,可以在这个位置暂停,比如发生 GC 时,需要暂停暂停所以活动线程,
  • 但是线程在这个时刻,还没有执行到一个安全点,所以该线程应该继续执行,到达下一个安全点的时候暂停,等待 GC 结束。(还在马路上,就不能休息,要到下一个服务区才能休息)

安全的选取

安全点是怎么选的?基本上是以程序「是否具有让程序长时间执行的特征」为标准进行选定的。

  • 长时间执行」的最明显特征就是指令序列复用,例如:方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生 Safepoint。

GC 时让线程停止的方式

如何在 GC 发生时让所有线程(这里不包括执行 JNI 调用的线程)都「跑」到最近的安全点上再停顿下来?

这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension),

  • 抢先式中断
    在 GC 发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它「跑」到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应 GC 事件。
  • 主动式中断
    主动式中断的思想是当 GC 需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。
    • 轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

安全区域

安全区域是一个安全点连续的代码段。

安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始 GC 都是安全的。我们也可以把 Safe Region 看做是被扩展了的 Safepoint。

在线程执行到 Safe Region 中的代码时,首先标识自己已经进入了 Safe Region

  • 那样,当在这段时间里 JVM 要发起 GC 时,就不用管标识自己为 Safe Region 状态的线程了。
    • (那些没标记为安全区域的怎么处理?)
  • 在线程要离开 Safe Region 时,它要检查系统是否已经完成了==根节点枚举==(或者是整个 GC 过程)[因为其他步骤可能不需要 stop the world ]
    • 如果完成了,那线程就继续执行,
    • 否则它就必须等待直到收到可以安全离开 Safe Region 的信号为止。

垃圾收集器

内存回收如何进行是由虚拟机所采用的 GC 收集器决定的,而通常虚拟机中往往不止有一种 GC 收集器。

如果说收集算法是内存回收的方法论,那么垃圾回收器就是内存回收的具体实现

虚拟机规范中对于垃圾回收器应该如何实现并没有任何规定。

hotspot

图 3-5 展示了 7 种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。

我们选择的只是对具体应用最合适的收集器。

以下分析的关注点:

  • 收集器的特点:
    • 工作线程数?
    • 工作过程中需不需要 stop the word?
      • 需要的话具体是哪个过程需要?
  • 能与哪个收集器搭配使用?

Serial 收集器

一个单线程的收集器。单线程指的是它用单条线程去完成垃圾收集工作

  • 重点:在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。

serial serial old

实际上到现在为止,它依然是虚拟机运行在 Client 模式下的默认新生代收集器

优点:简单而高效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Serial 收集器专心做垃圾收集自然可以获得最高的单线程收集效率。

Serial 收集器对于运行在 Client 模式下的虚拟机来说是一个很好的选择
因为在用户的桌面应用场景中,分配给虚拟机管理的内存一般来说不会很大,停顿时间完全可以控制在几十毫秒最多一百多毫秒以内,只要不是频繁发生,这点停顿是可以接受的。

ParNew 收集器

ParNew 收集器其实就是 Serial 收集器的多线程版本

parnew serial old

是许多运行在 Server 模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关但
很重要的原因是,除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作

两个名词:并发和并行。

  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个 CPU 上。
  • 二者关系:并行是并发的一个子集

ParallelScavenge 收集器

Parallel Scavenge 收集器是一个新生代收集器, 与 ParNew 很相似。但它的关注点与其他收集器不同,CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标是达到一个==可控制==的吞吐量(Throughput)。实际上二者概念存在重叠的地方。因为 GC 过程中用户线程停顿时间缩短,那么吞吐量也就上去了,只不过要考虑用户代码执行时间与停顿时间的比例。

吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即

  • 吞吐量 = 运行用户代码时间/(运行用户代码时间+垃圾收集时间),假设虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。
  • 高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

MaxGCPauseMillis 参数允许的值是一个大于 0 的毫秒数,收集器将尽可能地保证内存回收花费的时间不超过设定值。注意:GC 停顿时间缩短是以牺牲吞吐量和新生代空间来换取的:系统把新生代调小一些,这也直接导致垃圾收集发生得更频繁一些。停顿时间的确在下降,但吞吐量也降下来了。

GCTimeRatio 参数的值是一个大于 0 且小于 100 的整数,也就是垃圾收集时间占总时间的比率,相当于是吞吐量的倒数。如果把此参数设置为 19,那允许的最大 GC 时间就占总时间的 5%(即 1/(1+19)),默认值为 99,就是允许最大 1 %(即 1/(1+99))的垃圾收集时间。

由于与吞吐量关系密切,Parallel Scavenge 收集器也经常称为「吞吐量优先」收集器

Parallel Scavenge 收集器还有一个开关参数 -XX:+UseAdaptiveSizePolicy当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、Eden 与 Survivor 区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为 ==GC 自适应的调节策略==(GC Ergonomics)。自适应调节策略也是 Parallel Scavenge 收集器与 ParNew 收集器的一个重要区别。

SerialOld 收集器

Serial Old 是 Serial 收集器的老年代版本,使用「标记-整理」算法。

  • 这个收集器的主要意义也是在于给 Client 模式下的虚拟机使用

  • 如果在 Server 模式下,那么它主要还有两大用途:

    • 一种用途是在 JDK 1.5 以及之前的版本中与 Parallel Scavenge 收集器搭配使用

    • 另一种用途就是作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

      serial serial old

Parallel Scavenge 收集器架构中本身有 PS MarkSweep 收集器来进行老年代收集,并非直接使用了 Serial Old 收集器,但是这个 PS MarkSweep 收集器与 Serial Old 的实现非常接近

ParallelOld 收集器

Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和「标记-整理」算法。

这个收集器是在 JDK 1.6 中才开始提供的,此前,新生代的 Parallel Scavenge 收集器一直处于比较尴尬的状态。因为 JDK1.6 以前只能选择 serial old 作为老年代收集器。(唯一能选择的队友不行)

直到 Parallel Old 收集器出现后,「吞吐量优先」收集器终于有了比较名副其实的应用组合,在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器

parallel scavenge parallel old

CMS 收集器

CMS 收集器(Concurrent Mark Sweep)在 JDK1.5 时期被推出,这款收集器是 HotSpot 虚拟机中第一款真正意义上的==并发==收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作

CMS 作为老年代的收集器,却无法与 JDK 1.4.0 中已经存在的新生代收集器 Parallel Scavenge 配合工作。

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。适合于交互型的应用。希望系统停顿时间最短,以给用户带来较好的体验。CMS 收集器就非常符合这类应用的需求。
从名字(包含「Mark Sweep」)上就可以看出,CMS 收集器是基于「标记—==清除==」算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为 4 个步骤,包括:

  • 初始标记(CMS initial mark) ,需要「Stop The World
    仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快
  • 并发标记(CMS concurrent mark)
    进行 GC RootsTracing
  • 重新标记(CMS remark),需要「Stop The World
    为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
  • 并发清除(CMS concurrent sweep)

由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。通过图 3-10 可以比较清楚地看到 CMS 收集器的运作步骤中并发和需要停顿的时间。
concurrent mark sweep

缺点

  • 对 CPU 资源非常敏感
    其实,面向并发设计的程序都对 CPU 资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说 CPU 资源)而导致应用程序变慢,总吞吐量会降低。
    • CMS 默认启动的回收线程数是(CPU 数量 + 3)/ 4
      • 也就是当 CPU 在 4 个以上时,并发回收时垃圾收集线程不少于 25%的 CPU 资源,并且随着 CPU 数量的增加而下降。
      • 但是当 CPU 不足 4 个(譬如 2 个)时,CMS 对用户程序的影响就可能变得很大.
    • 用户程序在 GC 过程中执行 缓慢的问题,推出了增量式 CMS(i-CMS),在并发标记、清除时让 GC 线程与用户线程交替执行,尽量减少 GC 线程独占资源的时间,虽然这样会导致整个垃圾回收过程变长。但是效果不明显,目前版本中已经不再推荐使用。
  • 无法处理浮动垃圾(Floating Garbage)
    可能出现「Concurrent Mode Failure」失败而导致另一次 Full GC 的产生。
    • 由于 CMS 并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法在当次收集中处理掉它们,只好留待下一次 GC 时再清理掉。这一部分垃圾就称为「浮动垃圾」。
  • 收集结束时会产生大量空间碎片。(因为使用了「标记-清除」算法)
    空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次 Full GC 。
    • 为了解决这个问题,CMS 收集器提供了一个-XX:+UseCMSCompactAtFullCollection 开关参数(默认就是开启的),用于在 CMS 收集器顶不住要进行 FullGC 时开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间不得不变长
    • 另外一个参数-XX:CMSFullGCsBeforeCompaction,这个参数是用于设置执行多少次不压缩的 Full GC 后,跟着来一次带压缩的(默认值为 0,表示每次进入 Full GC 时都进行碎片整理)。

G1 收集器

G1(Garbage-First)是一款面向服务端应用的垃圾收集器。它有以下几个优点:

  • 并行与并发
    G1 能充分利用多 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短Stop-The-World停顿的时间
  • 分代收集
    不需要其他收集器配合就能独立管理整个 GC 堆,能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次 GC 的旧对象以获取更好的收集效果。
  • 空间整合:无内存碎片。
    与 CMS 的「标记—清理」算法不同,G1
    • 整体来看是基于「标记—整理」算法实现的收集器
    • 局部(两个 Region 之间)上来看是基于「复制」算法实现的
    • 这两种算法都意味着G1 运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC。
  • 可预测的停顿
    降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。使用 G1 收集器时,Java 堆的内存布局就与其他收集器有很大差别(其他收集器都是对整一个老年代或者新生代进行操作),它将整个 Java 堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分 Region(不需要连续)的集合

之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表每次根据允许的收集时间,优先回收价值最大的 Region(这也就是 Garbage-First 名称的来由)。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限的时间内可以获取尽可能高的收集效率。

Region 不可能是孤立的。一个对象分配在某个 Region 中,可以与整个 Java 堆任意的对象发生引用关系。那在做可达性判定确定对象是否存活的时候,岂不是还得扫描整个 Java 堆才能保证准确性?在 G1 收集器中,Region 之间的对象引用以及其他收集器中的新生代与老年代之间的对象
引用,虚拟机都是使用 ==Remembered Set== 来避免全堆扫描的。

  • G1 中每个 Region 都有一个与之对应的 Remembered Set,虚拟机发现程序在对 Reference 类型的数据进行写操作时,会产生一个 Write Barrier 暂时中断写操作,检查 Reference 引用的对象是否处于不同的 Region 之中(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,便通过 CardTable 把相关引用信息记录到被引用对象所属的 Region 的 Remembered Set 之中。
  • 当进行内存回收时,在 GC 根节点的枚举范围中加入 Remembered Set 即可保证不对全堆扫描也不会有遗漏。

如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:

  • 初始标记(Initial Marking)
  • 并发标记(Concurrent Marking)
  • 最终标记(Final Marking)
  • 筛选回收(Live Data Counting and Evacuation)
    • 哪一块回收的收益大就选哪一块。

G1 的前几个步骤的运作过程和 CMS 有很多相似之处。

  • 初始标记阶段
    仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的 Region 中创建新对象,这阶段需要停顿线程,但耗时很短
  • 并发标记阶段
    是从 GC Root 开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行
  • 最终标记阶段
    是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中,这阶段需要停顿线程,但是可并行执行
  • 筛选回收阶段
    对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。

g1

组合策略

在进行 JVM 调优的过程中,并非任何一种新生代 GC 策略都可以和另一种年老代 GC 策略进行配合工作,所以,我们应该知道,哪些种组合可以有效地进行 GC,而且应该在什么样的应用场景下选择哪一种组合,如下表所示:

新生代 GC 策略 年老代 GC 策略 说明
组合 1 Serial Serial Old Serial 和 Serial Old 都是单线程进行 GC,特点就是 GC 时暂停所有应用线程。
组合 2 Serial CMS+Serial Old CMS(Concurrent Mark Sweep)是并发 GC,实现 GC 线程和应用线程并发工作,不需要暂停所有应用线程。另外,当 CMS 进行 GC 失败时,会自动使用 Serial Old 策略进行 GC。
组合 3 ParNew CMS 使用-XX:+UseParNewGC 选项来开启。ParNew 是 Serial 的并行版本,可以指定 GC 线程数,默认 GC 线程数为 CPU 的数量。可以使用-XX:ParallelGCThreads 选项指定 GC 的线程数。如果指定了选项-XX:+UseConcMarkSweepGC 选项,则新生代默认使用 ParNew GC 策略。
组合 4 ParNew Serial Old 使用-XX:+UseParNewGC 选项来开启。新生代使用 ParNew GC 策略,年老代默认使用 Serial Old GC 策略。
组合 5 Parallel Scavenge Serial Old Parallel Scavenge 策略主要是关注一个可控的吞吐量:应用程序运行时间 / (应用程序运行时间 + GC 时间),可见这会使得 CPU 的利用率尽可能的高,适用于后台持久运行的应用程序,而不适用于交互较多的应用程序。
组合 6 Parallel Scavenge Parallel Old Parallel Old 是 Parallel Scavenge 收集器的老年代版本。适用于注重吞吐量以及 CPU 资源敏感的场合
7 G1 G1 是 Garbage First 垃圾收集器。

理解 GC 日志

GC 日志开头的 「[GC」和「[Full GC」说明了这次垃圾收集的==停顿类型==,而不是用来区分新生代 GC 还是老年代 GC 的

  • 如果有「Full」,说明这次 GC 是发生了 Stop-The-World 的,这段新生代收集器 ParNew 的日志也会出现「[Full GC」(这一般是因为出现了分配担保失败之类的问题,所以才导致 STW)。
  • 如果是调用 System.gc()方法所触发的收集,那么在这里将显示「[Full GC(System)」。

接下来的「[DefNew」、「[Tenured」、「[Perm」表示 GC 发生的区域,这里显示的区域名称与使用的 GC 收集器是密切相关的,

  • Serial 收集器中的新生代名为 「Default New Generation」,所以显示的是「[DefNew」。
  • 如果是 ParNew 收集器,新生代名称就会变为「[ParNew」,意为 「Parallel New Generation」。
  • 如果采用 Parallel Scavenge 收集器,那它配套的新生代称为「PSYoungGen」

内存分配与回收策略

对象的内存分配,往大方向讲,就是在堆上分配(但也可能经过 JIT 编译后被拆散为标量类型并间接地栈上分配),对象主要分配在新生代的 Eden 区上,如果启动了本地线程分配缓冲,将按线程优先在 TLAB 上分配*。少数情况下也可能会直接分配在老年代中

  • 注: TLAB 全称为 Thread Local Allocation Buffered

分配的规则并不是百分之百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。以下为几条最普遍的内存分配规则。

  • 对象优先在 Eden 分配、
  • 大对象直接进入老年代、
  • 长期存活的对象将进入老年代 、
  • 动态对象年龄判定 、
  • 空间分配担保

对象优先在 Eden 分配

大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC

Minor GC 和 Full/major GC 有什么不一样吗?

  • 新生代 GC(Minor GC):指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
  • 老年代 GC(Major GC/Full GC):指发生在老年代的 GC,出现了 Major GC,经常会伴随至少一次的 Minor GC(但非绝对的,在 Parallel Scavenge 收集器的收集策略里就有直接进行 Major GC 的策略选择过程)。Major GC 的速度一般会比 Minor GC 慢 10 倍以上

大对象直接进入老年代

所谓的大对象是指,需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串以及数组。

虚拟机提供了一个-XX:PretenureSizeThreshold 参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制(复习一下:新生代采用复制算法收集内存)。

长期存活的对象将进入老年代

为了做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器

  • 如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并且对象年龄设为 1。
  • 对象在 Survivor 区中每「熬过」一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就将会被晋升到老年代中。
    • 对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 设置。

动态对象年龄判定

虚拟机并不是永远地要求对象的年龄必须达到了 MaxTenuringThreshold才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold中要求的年龄。

也就是说,同龄的小伙伴的内存大于总内存的一半。那么 >= 该年龄的就算老了。

空间分配担保

在发生 Minor GC 之前,

  • 虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间
    • 如果这个条件成立,那么 Minor GC 可以确保是安全的。
    • 如果不成立,则虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。
      • 如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象容量的==平均大小==,
        • 如果大于,将尝试着进行一次 Minor GC,尽管这次 Minor GC 是有风险的;
        • 如果小于,或者 HandlePromotionFailure 设置不允许冒险,那这时也要改为进行一次 Full GC
      • 如果不允许,改为进行一次 Full GC

「冒险」是冒了什么风险?

  • 一句话就是:老年代的最大可用连续空间可能小于新生代所有存活对象总空间,就会导致 Full GC,本来是应该直接执行 Full GC 的,但是没有直接执行,导致浪费了时间。
  • 具体而言:
    新生代使用复制收集算法,但为了内存利用率,只使用其中一个 Survivor 空间来作为轮换备份,因此当出现大量对象在 Minor GC 后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把 Survivor 无法容纳的对象直接进入老年代。老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行 Full GC 来让老年代腾出更多空间。

取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次 Minor GC 存活后的对象突增,远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。
如果出现了 HandlePromotionFailure 失败,那就只好在失败后重新发起一次 Full GC。

虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将 HandlePromotionFailure 开关打开,避免 Full GC 过于频繁

常见问题

你能不能谈谈,GC 是在什么时候,对什么东西,做了什么事情?

此问题摘自《深入理解 Java 虚拟机》作者的一篇博文,详见 一个面试官对面试问题的分析

java的gc为什么要分代?

答案可参考 R 大的这个回答

总结

没有固定收集器、参数组合,也没有最优的调优方法,虚拟机也就没有什么必然的内存回收行为。

学习虚拟机内存知识,如果要到实践调优阶段,那么必须了解每个具体收集器的行为、优势和劣势、调节参数。

参考资料与学习资源推荐

  • 《深入理解 Java 虚拟机》

本文大部分内容都出自《深入理解 Java 虚拟机》 第三章,若文中有不正确的结论、说法,请大家指出,共同探讨,共同进步,谢谢!

Show Comments
0%